emoji selecterの再実装版
実装動機
原点に戻って、もっとシンプルに実装したいと思った
実装方法
3. 型つける
4. その他使いやすく修正
操作方法を外部から指定する
補完候補を外部から注入する
code:sh
code:script.ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
import { Asearch } from "../deno-asearch/mod.ts";
import {
editor,
textInput,
insertText,
press,
caret,
getInternalLink,
takeCursor,
takeSelection,
} from "../scrapbox-userscript-std/dom.ts";
import { loadEmojis } from "./load.ts";
import { makeBox } from "./ui.ts";
import { Candidate } from "./types.ts";
import { getMaxDistance } from "./distance.ts";
const projectName = scrapbox.Project.name;
const emojis: Candidate[] = await loadEmojis(projectName);
code:types.ts
export interface Candidate {
name: string;
path: string;
icon: string;
}
code:load.ts
import { Candidate } from "./types.ts";
export const loadEmojis = async (project: string): Promise<Candidate[]> => {
const res = await fetch(/api/pages/${project}?limit=1000);
const { pages } = await res.json();
return pages.flatMap((page) => {
if (!page.image) return [];
return {
name: page.title,
path: page.title,
icon: /api/pages/${project}/${page.title}/icon,
};
});
};
code:ui.ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
import { Candidate } from "./types.ts";
export interface Item extends Candidate {
onClick: () => void;
}
export interface DropDown {
/** 補完でき、かつ補完候補が存在する場合のみ、補完windowを開く
*
* 補完候補がない場合は何も映さない
*/
open: () => void;
/** 補完を終了する */
close: () => void;
isOpen: () => boolean;
}
export const makeBox = (): HTMLDivElement => {
const box = document.createElement("div");
box.classList.add("form-group");
box.style.position = "absolute";
const container = document.createElement("div");
container.classList.add("dropdown");
box.append(container);
const items = document.createElement("ul");
items.classList.add("dropdown-menu");
container.append(items);
editor()!.append(box);
const open = () => container.classList.add("open");
const close = () => container.classList.remove("open");
const isOpen = () => container.classList.contains("open");
const setItems = (items: Candidate[]) => {
items.textContent = "";
items.append(...items.map((item) => makeItem(item)));
};
/** 最初の項目にfocusをあてる
*
* 以降のアイテムはブラウザの標準操作方法(矢印キーかtabキー)で遷移できる
*/
const focus = () => {
const focused = items.querySelector(":focus");
if (focused) return;
items.firstElementChild?.firstElementChild?.focus?.();
};
const confirm = () => {
items.querySelector(":focus")?.click?.();
close();
};
return { open, close, isOpen, setItems, focus, confirm };
};
code:script.ts
scrapbox.PageMenu.addMenu({
title: "emoji",
});
scrapbox.PageMenu("emoji").addItem({
title: "load emojis from /emoji",
onClick: async () => {
for (const emoji of await loadEmojis("emoji")) {
if(emojis.some((e) => emoji.name === e.name)) continue;
emojis.push(emoji);
}
},
});
const detectLink = () => {
const { line, char } = caret().position;
const link = getInternalLink(line, char);
if (!link) return undefined;
const text = /^\^\+\]$/.test(link.textContent) ?
link.textContent.replace(/^\[(^\]+)\]$/, "$1").trim() : link.textContent.trim();
const start = getIndex(getChars(link).next().value);
return text.startsWith(":") ? {
text: text.slice(1),
raw: [${text}],
pos: {
line,
char: start,
} : undefined;
};
let completing = false;
const callback = () => {
const text = detectLink()?.text;
if (text !== undefined) return;
handleEnd();
};
const handleStart = () => {
if (completing) return;
completing = true;
const cursor = takeCursor();
cursor.addChangeListener(callback);
};
const handleEnd = () => {
if (!completing) return;
completing = false;
const cursor = takeCursor();
cursor.removeChangeListener(callback);
close();
};
textInput!.addEventListener("input", (e) => {
if (e.isComposing) return;
const text = detectLink()?.text;
if (text === undefined) {
handleEnd();
return;
}
const { match } = Asearch( ${text} );
const compare = new Intl.Collator().compare;
setItems(emojis
.flatMap((emoji) => {
const result = match(emoji.name, getMaxDistancetext.length); if (!result.found) return [];
return [{
distance: result.distance,
onClick: () => {
const link = detectLink();
if (link === undefined) {
handleEnd();
return;
}
const selection = takeSelection();
selection.setSelection({
start: {
line: link.pos.line,
char: link.pos.char,
},
end: {
line: link.pos.line,
char: link.pos.char + link.raw.length -1,
},
});
await insertText();
},
...emoji
}];
})
.sort((a, b) => a.distance === b.distance ? compare(a.name, b.name) : a.distance - b.distance)
);
open();
});
editor.keydown( e => {
const key = e.key;
const cursor = $('#text-input')0; stack += e.key;
let focused = $(':focus');
if(focused.is(items.find('li > a'))){
cursor.focus();
}
}
switch(key){
case 'Backspace':
stack = stack.slice(0, stack.length - 1);
if(stack.length === 0){
close();
return;
}
break;
case 'ArrowUp':
let focusedUp = $(':focus');
if( focusedUp.is(items.find('li > a').eq(0)) ){
e.stopPropagation();
cursor.focus();
}else if( !focusedUp.is(items.find('li > a')) ){
close();
return;
}
break;
case 'ArrowDown':
let focusedDown = $(':focus');
if( !focusedDown.is(items.find('li > a'))) {
e.stopPropagation();
e.preventDefault();
items.find("li > a").eq(0).focus();
}
break;
case 'Escape':
case 'ArrowLeft':
case 'ArrowRight':
case 'Home':
case 'End':
case 'PageUp':
case 'PageDown':
close();
break;
case 'Enter':
if( stack.length === 1 ){
close();
break;
}
let focused = $(':focus');
if(!focused.is(items.find('li > a'))){
e.stopPropagation();
e.preventDefault();
items.find('li > a').eq(0).click();
}
break;
}
if( stack.length <= 1 || !key.match(/^\w\s\:\-\+$|Backspace/)) return; const matchedEmoji = fizzSearch(stack, emojis)
if( matchedEmoji.length === 0){
close();
return;
}
const newItems = $('<ul>').addClass('dropdown-menu');
matchedEmoji.forEach( ( emoji, index) => {
if( index > 30 ) return;
newItems.append(makeItem(emoji.name, emoji.src));
a.on('click', () => {
cursor.focus();
replaceText(stack, cursor, emoji.path);
})
a.on('keypress', ev => {
if(ev.key === "Enter"){
ev.preventDefault();
ev.stopPropagation();
replaceText(stack, cursor, emoji.path);
}
})
})
code:ui.ts
const makeItem = ({ name, path, onClick }: Item): HTMLLiElement => {
const li = document.createElement("li");
li.classList.add("dropdown-item");
const a = document.createElement("a");
a.tabIndex = 0;
a.addEventListener("click", onClick);
const icon = document.createElement("img");
icon.classList.add("icon");
icon.src = path;
icon.style.height = "170x";
icon.style.float = "left";
const nameTag = document.createElement("div");
nameTag.textContent = :${name}:;
a.append(icon, nameTag);
li.append(a);
return li;
};
code:script.ts
items.replaceWith(newItems);
items = newItems;
let css = {};
cursor.style.cssText.split(';').filter( text => text !== '' )
.forEach( text => {
const props = text.split(':').map( text => text.replace(' ', '').replace('px', ''));
});
box.css({
top: ${parseInt(css.top) + parseInt(css.height) + 3}px,
left: ${css.left}px,
});
})
code:distance.ts
export const getMaxDistance = [
0, // 空文字のとき
0, 0,
1, 1,
2, 2, 2, 2,
3, 3, 3, 3, 3, 3,
4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
];